/*
 * $Id: PlaylistService.java 629 2008-09-24 02:08:52Z jasta00 $
 *
 * Copyright (C) 2008 Josh Guilfoyle <jasta@devtcg.org>
 *
 * This program is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the
 * Free Software Foundation; either version 2, or (at your option) any
 * later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * General Public License for more details.
 */

package org.devtcg.five.music.service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.devtcg.five.provider.Five;
import org.devtcg.five.service.ICacheService;

import android.app.NotificationManager;
import android.app.Service;
import android.content.ComponentName;
import android.content.ContentUris;
import android.content.Intent;
import android.content.ServiceConnection;
import android.database.Cursor;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.IBinder;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.util.Log;
import android.widget.Toast;

public class PlaylistService extends Service implements ServiceConnection,
  MediaPlayer.OnBufferingUpdateListener, MediaPlayer.OnErrorListener,
  MediaPlayer.OnCompletionListener, MediaPlayer.OnPreparedListener
{
	public static final String TAG = "PlaylistService";

	/* Lock allowing us to guarantee the presence of the cache service
	 * on demand. */
	private final Object mCacheLock = new Object();

	/* Lock synchronizing resource access from binder threads.  This is more
	 * of a hint than a rule as we know that only one thread will be making
	 * changes to the playlist state at any time. */
	private final Object mBinderLock = new Object();

	ICacheService mCache = null;

	MediaPlayer mPlayer = null;
	NotificationManager mNM = null;
	
	final List<Long> mPlaylist =
	  Collections.synchronizedList(new ArrayList<Long>());

	volatile int mPosition = -1;
	volatile boolean mPlaying = false;
	volatile boolean mPaused = false;
	
	final Map<Long, SongDownloadHandler.DownloadStats> mDownloads =
	  Collections.synchronizedMap(new HashMap<Long,
	    SongDownloadHandler.DownloadStats>());

	final IPlaylistChangeListenerCallbackList mChangeListeners =
	  new IPlaylistChangeListenerCallbackList();

	final IPlaylistMoveListenerCallbackList mMoveListeners =
	  new IPlaylistMoveListenerCallbackList();
	
	final IPlaylistDownloadListenerCallbackList mDownloadListeners =
	  new IPlaylistDownloadListenerCallbackList();
	
	final IPlaylistBufferListenerCallbackList mBufferListeners =
	  new IPlaylistBufferListenerCallbackList();

	/**
	 * Hack to work-around limitations in the MediaPlayer interface in 0.9r1.
	 * The MediaPlayer can only be fed from either an HTTP url or a local file
	 * on disk.  We require that it use an HTTP stream but also cache the
	 * downloaded result on disk, which is not currently supported either.
	 * This means that we need to implement a local HTTP server which proxies
	 * requests along to our real remote server and then manages "teeing" the
	 * stream to disk.
	 */
	LocalHttpServerHack mLocalHttp = null;

	@Override
	public void onCreate()
	{
		super.onCreate();

		bindCacheService();

		mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
		assert mNM != null;
	}

	@Override
	public void onDestroy()
	{
		/* XXX: Synchronization may not be necessary here as onDestroy() is
		 * likely to have been called after any binder threads were nuked. */
		synchronized(this) {
			if (mLocalHttp != null)
				mLocalHttp.shutdown();

			mLocalHttp = null;

			if (mPlayer != null)
			{
				mPlayer.release();
				mPlayer = null;
			}
		}

		unbindService(this);

		super.onDestroy();
	}

	private void bindCacheService()
	{
		ComponentName service = new ComponentName("org.devtcg.five",
		  "org.devtcg.five.service.CacheService");

		bindService(new Intent().setComponent(service),
		  this, BIND_AUTO_CREATE);
	}

	private void waitForCacheService()
	{
		long now = -1;
		long then;

		/* Wait up to 1s before critically failing. */
		int timeout = 1000;

		synchronized(mCacheLock) {
			while (mCache == null)
			{
				then = now;
				now = System.currentTimeMillis();

				if (then >= 0)
				{
					timeout -= (now - then);

					if (timeout <= 0)
					{
						throw new IllegalStateException("Cannot proceed " +
						  "without a connection to the cache service");
					}
				}

				try { mCacheLock.wait(timeout); }
				catch (InterruptedException e) {}
			}
		}
	}

	public void onServiceConnected(ComponentName name, IBinder binder)
	{
		synchronized(mCacheLock) {
			assert mCache == null;
			mCache = ICacheService.Stub.asInterface(binder);
			mCacheLock.notify();
		}
	}

	public void onServiceDisconnected(ComponentName name)
	{
		/* AIEEEEEE! */
		throw new IllegalStateException("Cannot proceed without a connection" +
		  " to " + name);
	}

	@Override
	public IBinder onBind(Intent intent)
	{
		return mBinder;
	}

	private void allocateMediaPlayer()
	{
		if (mPlayer == null)
			mPlayer = new MediaPlayer();
		else
			mPlayer.reset();
	}

	private final SongDownloadHandler mHandler = new SongDownloadHandler()
	{
		@Override
		public void onAborted(long songId)
		{
			assert mDownloads.containsKey(songId) == true;

			Log.i(TAG, "Download aborted (song " + songId + ")");
			mDownloads.remove(songId);

			mDownloadListeners.broadcastOnDownloadCancel(songId);
		}

		@Override
		public void onError(long songId, String err)
		{
			assert mDownloads.containsKey(songId) == true;

			Log.e(TAG, "Download error (song " + songId + "): " + err);
			mDownloads.remove(songId);

			mDownloadListeners.broadcastOnDownloadError(songId, err);
		}

		@Override
		public void onFinished(long songId)
		{
			assert mDownloads.containsKey(songId) == true;

			Log.i(TAG, "Download finished (song " + songId + ")");
			mDownloads.remove(songId);

			mDownloadListeners.broadcastOnDownloadFinish(songId);
		}
		
		@Override
		public void onBegin(long songId)
		{
			assert mDownloads.containsKey(songId) == false;

			DownloadStats stats = new DownloadStats();
			mDownloads.put(songId, stats);

			mDownloadListeners.broadcastOnDownloadBegin(songId);
		}

		@Override
		public void onRecv(long songId, long recvd)
		{
			DownloadStats stats = mDownloads.get(songId);
			assert stats != null;

			assert recvd >= stats.recvd;
			stats.recvd = recvd;

			mDownloadListeners.broadcastOnDownloadUpdate(songId, recvd,
			  stats.size);
		}

		@Override
		public void onSetLength(long songId, long length)
		{
			DownloadStats stats = mDownloads.get(songId);
			assert stats != null;

			stats.size = length;
		}
	};

	/**
	 * Does all the heavy lifting to play a song.  Checks the cache,
	 * manages the local HTTP server / streaming, and (later) playback
	 * events.
	 *
	 * TODO: Refactor out of PlaylistService.
	 *
	 * @return
	 *   True if playback is starting; false if an unforeseen error has
	 *   occurred.
	 */
	private synchronized boolean playInternal(long songId)
	{
		/* Block waiting for the cache service to become connected now that
		 * we critically require.  If the service is not made available 
		 * within a reasonable time, we will fail critically. */
		waitForCacheService();
		assert mCache != null;

		if (mLocalHttp == null)
		{
			try {
				mLocalHttp = new LocalHttpServerHack(8080, mHandler,
				  getContentResolver(), mCache);
				mLocalHttp.start();
			} catch (IOException e) {
				Log.e(TAG, "Oh god! Aieee!", e);
				return false;
			}
		}

		Uri songUri = ContentUris.withAppendedId(Five.Music.Songs.CONTENT_URI,
		  songId);

		Cursor c = getContentResolver().query(songUri,
		  new String[] { Five.Music.Songs.CONTENT_ID }, null, null, null);

		String cachePath;
		long contentId;
		long sourceId;

		try {
			if (c.moveToFirst() == false)
				return false;

			long _id = c.getLong(0);

			c.close();
			c = null;

			Uri contentUri =
			  ContentUris.withAppendedId(Five.Content.CONTENT_URI,
			    _id);

			c = getContentResolver().query(contentUri,
			  new String[] { Five.Content.CONTENT_ID, Five.Content.SOURCE_ID,
			    Five.Content.CACHED_PATH }, null, null, null);

			if (c.moveToFirst() == false)
				return false;

			contentId = c.getLong(0);
			sourceId = c.getLong(1);
			cachePath = c.getString(2);
		} finally {
			c.close();
		}

		assert sourceId >= 0;
		assert contentId >= 0;

		allocateMediaPlayer();

		mPlayer.setOnBufferingUpdateListener(this);
		mPlayer.setOnCompletionListener(this);
		mPlayer.setOnErrorListener(this);
		mPlayer.setOnPreparedListener(this);

		try {
			if (cachePath != null)
			{
				mPlayer.setDataSource(cachePath);
				mPlayer.prepareAsync();
			}
			else
			{
				mBufferListeners.broadcastOnBufferingUpdate(songId, 0);

				/* Do note that even though the MediaPlayer won't help us
				 * observe download progress, the LocalHttpServerHack will through
				 * the handler we supply. */
				String url = LocalHttpServerHack.makeUri(sourceId, contentId,
				  songId);
				mPlayer.setDataSource(url);
				mPlayer.prepareAsync();
			}
		} catch (IOException e) {
			Log.e(TAG, "Shouldn't happen.", e);
			return false;
		}

		return true;
	}

	public void onBufferingUpdate(MediaPlayer mp, int percent)
	{
		/* TODO: Fire buffer listeners.  We will have to wait for Android to
		 * not be broken first, though.  In the meantime, we abuse
		 * LocalHttpServerHack to get our status updates. */
		Log.d(TAG, "onBufferUpdate(" + percent + ")");
	}

	public boolean onError(MediaPlayer mp, int what, int extra)
	{
		Toast.makeText(this, "Critical MediaPlayer error: what=" + what,
		  Toast.LENGTH_SHORT).show();

		Log.d(TAG, "Media playback error, what=" + what);
		return false;
	}

	public void onCompletion(MediaPlayer mp)
	{
		assert mp.isPlaying() == true;
		assert mPlaying == true;
		assert mPaused == false;

		try { 
			mBinder.next();
		} catch (RemoteException e) {}
	}

	public void onPrepared(MediaPlayer mp)
	{
		assert mp.isPlaying() == false;
		assert mPlaying == true;
		assert mPaused == false;

		mp.start();

		assert mPosition >= 0;
		long songId = mPlaylist.get(mPosition);

		mBufferListeners.broadcastOnBufferingUpdate(songId, 100);
	}
	
	private final IPlaylistService.Stub mBinder = new IPlaylistService.Stub()
	{
		/*-********************************************************************/

		public void registerOnMoveListener(IPlaylistMoveListener l)
		  throws RemoteException
		{
			mMoveListeners.register(l);
		}

		public void unregisterOnMoveListener(IPlaylistMoveListener l)
		  throws RemoteException
		{
			mMoveListeners.unregister(l);
		}

		public int next()
		  throws RemoteException
		{
			int next;

			synchronized(mBinderLock) {
				next = mPosition + 1;

				if (mPlaylist.size() >= next)
				{
					stop();
					jump(-1);
					return -1;
				}
			}

			jump(next);
			return next;
		}

		public int previous()
		  throws RemoteException
		{
			int prev;

			synchronized(mBinderLock) {
				prev = mPosition - 1;

				if (prev < 0)
				{
					stop();
					jump(-1);
					return -1;
				}
			}

			jump(prev);
			return prev;
		}

		public void jump(int pos)
		  throws RemoteException
		{
			if (pos < -1 || pos >= mPlaylist.size())
				return;

			boolean shouldRestart =
			  (isPlaying() == true || isPaused() == true);

			if (pos == -1)
				assert shouldRestart == false;
			else if (shouldRestart == true)
				stop();

			synchronized(mBinderLock) {
				mPosition = pos;
			}

			mMoveListeners.broadcastOnJump(pos);

			if (pos >= 0 && shouldRestart == true)
				play();
		}

		public void play()
		  throws RemoteException
		{
			if (isPlaying() == true || isPaused() == true)
			{
				mPaused = false;
				seek(0);
				return;
			}

			long songId; 

			synchronized(mBinderLock) {
				if (mPlaylist.isEmpty() == true)
					return;

				if (mPosition == -1)
					jump(0);

				songId = mPlaylist.get(mPosition);

				mPlaying = true;
			}

			/* TODO: How should we handle this?  Gracefully destroying the
			 * service may be a good idea. */
			boolean ret = playInternal(songId);
			assert ret == true;
			
			mMoveListeners.broadcastOnPlay();
		}

		public void pause()
		  throws RemoteException
		{
			if (isPlaying() == false)
				return;

			synchronized(mBinderLock) {
				assert mPlayer != null;
				mPlayer.pause();
				mPaused = true;
			}
			
			mMoveListeners.broadcastOnPause();
		}

		public void unpause()
		  throws RemoteException
		{
			if (isPaused() == false)
				return;

			synchronized(mBinderLock) {
				assert mPlayer != null;
				mPlayer.start();
				mPaused = false;
			}

			mMoveListeners.broadcastOnUnpause();
		}

		public void stop()
		  throws RemoteException
		{
			if (isPlaying() == false && isPaused() == false)
				return;

			synchronized(mBinderLock) { 
				assert mPlayer != null;
				mPlayer.stop();
				mPaused = false;
				mPlaying = false;
			}

			mMoveListeners.broadcastOnStop();
		}

		public void seek(long pos)
		  throws RemoteException
		{
			synchronized(mBinderLock) {
				mPlayer.seekTo((int)pos);
			}

			mMoveListeners.broadcastOnSeek(pos);
		}

		/*-********************************************************************/

		public int getPosition()
		  throws RemoteException
		{
			return mPosition;
		}

		public long tell()
		  throws RemoteException
		{
			if (isPlaying() == false && isPaused() == false)
				return -1;

			assert mPlayer != null;
			return (long)mPlayer.getCurrentPosition();
		}

		public long getSongDuration()
		  throws RemoteException
		{
			return (long)mPlayer.getDuration();
		}
		
		public boolean isPlaying()
		  throws RemoteException
		{
			return mPlaying;
		}

		public boolean isStopped()
		  throws RemoteException
		{
			return mPlaying == false;
		}

		public boolean isPaused()
		  throws RemoteException
		{
			return mPaused;
		}

		public boolean isDownloading()
		  throws RemoteException
		{
			int n = getPosition();

			if (n == -1)
				return false;

			return mDownloads.containsKey(mPlaylist.get(n));
		}

		public boolean isOutputting()
		  throws RemoteException
		{
			if (mPlayer == null)
				return false;

			return mPlayer.isPlaying();
		}

		/*-********************************************************************/

		public List getPlaylist()
		  throws RemoteException
		{
			return mPlaylist;
		}

		public List getPlaylistWindow(int from, int to)
		  throws RemoteException
		{
			if (from >= to)
				return null;

			if (from < 0)
				return null;

			synchronized(mBinderLock) {
				if (mPlaylist.isEmpty() == true)
					return null;

				int n = mPlaylist.size();
				assert n > 0;

				if (to > n)
					to = n;

				/* XXX: We assume that Android's serialization will
				 * copy our list so as not to leak references. */ 
				return mPlaylist.subList(from, to);
			}
		}

		public int getPlaylistLength()
		  throws RemoteException
		{
			return mPlaylist.size();
		}

		public long getSongAt(int pos)
		  throws RemoteException
		{
			if (pos < 0)
				return -1;

			synchronized(mBinderLock) {
				if (pos >= mPlaylist.size())
					return -1;

				return mPlaylist.get(pos);
			}
		}

		public boolean isInPlaylist(long songId)
		  throws RemoteException
		{
			return mPlaylist.contains(songId);
		}

		public long peekNext()
		  throws RemoteException
		{
			Log.d(TAG, "UNIMPLEMENTED: peekNext()");
			return -1;
		}

		/*-********************************************************************/

		public void shuffle()
		  throws RemoteException
		{
		}

		public void setRepeat(int repeatMode)
		  throws RemoteException
		{
		}

		public int getRepeat()
		  throws RemoteException
		{
			return -1;
		}

		public void setRandom(boolean random)
		  throws RemoteException
		{
			assert random == false;
		}

		public boolean getRandom()
		  throws RemoteException
		{
			return false;
		}

		/*-********************************************************************/

		public void registerOnChangeListener(IPlaylistChangeListener l)
		  throws RemoteException
		{
			mChangeListeners.register(l);
		}

		public void unregisterOnChangeListener(IPlaylistChangeListener l)
		  throws RemoteException
		{
			mChangeListeners.unregister(l);
		}

		public void loadPlaylistRef(long playlistId)
		  throws RemoteException
		{
		}

		public long getPlaylistRef()
		  throws RemoteException
		{
			return -1;
		}

		public boolean isPlaylistRefLiteral()
		  throws RemoteException
		{
			return false;
		}

		public void clear()
		  throws RemoteException
		{
			stop();
			jump(-1);

			synchronized(mBinderLock) {
				mPlaylist.clear();
			}

			mChangeListeners.broadcastOnClear();
		}

		public void insert(long songId, int pos)
		  throws RemoteException
		{
			synchronized(mBinderLock) {
				mPlaylist.add(pos, songId);
			}
			
			mChangeListeners.broadcastOnInsert(songId, pos);

			synchronized(mBinderLock) {
				if (isPlaying() == false && isPaused() == false)
				{
					int n = mPlaylist.size();
					assert n > 0;

					mPosition = n - 1;
					play();
				}
			}
		}

		public void insertNext(long songId)
		  throws RemoteException
		{
			int n = getPosition();

			if (n == -1)
				append(songId);
			else
				insert(songId, n + 1);
		}

		public void prepend(long songId)
		  throws RemoteException
		{
			insert(songId, 0);
		}

		public void append(long songId)
		  throws RemoteException
		{
			insert(songId, mPlaylist.size());
		}

		public long remove(int pos)
		  throws RemoteException
		{
			long songId;

			synchronized(mBinderLock) {
				songId = mPlaylist.remove(pos);
			}

			mChangeListeners.broadcastOnRemove(pos);
			return songId;
		}

		public long move(int oldpos, int newpos)
		  throws RemoteException
		{
			Log.d(TAG, "UNIMPLEMENTED: move(int,int)");
			return -1;
		}

		/*-********************************************************************/

		public void registerOnDownloadListener(IPlaylistDownloadListener l,
		  long updateInterval)
		  throws RemoteException
		{
			mDownloadListeners.register(l);
		}

		public void unregisterOnDownloadListener(IPlaylistDownloadListener l)
		  throws RemoteException
		{
			mDownloadListeners.unregister(l);
		}

		public void registerOnBufferingListener(IPlaylistBufferListener l,
		  long updateInterval)
		  throws RemoteException
		{
			mBufferListeners.register(l);
		}

		public void unregisterOnBufferingListener(IPlaylistBufferListener l)
		  throws RemoteException
		{
			mBufferListeners.unregister(l);
		}
	};
}
